Skip to content

fix(tours): honour stripped legacy tour setting keys#1271

Merged
superdav42 merged 1 commit into
mainfrom
fix/tours-legacy-key-fallback
May 26, 2026
Merged

fix(tours): honour stripped legacy tour setting keys#1271
superdav42 merged 1 commit into
mainfrom
fix/tours-legacy-key-fallback

Conversation

@superdav42

@superdav42 superdav42 commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes remaining admin tours reappearing for users whose dismissed-tour state was saved before the tour ID normalization fix.

Affected URLs observed locally:

  • wp-admin/network/admin.php?page=wp-ultimo-checkout-forms
  • wp-admin/network/admin.php?page=wp-ultimo

Root cause

Prior fixes covered newer persistence paths:

However, older dismissals can still exist in WordPress user-settings with hyphens stripped entirely by the legacy cookie/settings path, for example:

  • wu_tour_checkoutformlist=1
  • wu_tour_wpultimodashboard=1

Current code checks the new meta key and the normalized legacy setting key, but not these stripped historical keys. Those users keep seeing tours they already dismissed.

Fix

  • Add legacy key discovery for both normalized and stripped historical key shapes.
  • Read persisted WordPress user-settings directly with get_user_option('user-settings', $user_id) when get_user_setting() does not find the normalized key.
  • When any legacy key proves the tour was already dismissed, backfill the new wu_tour_finished_* meta flag so future reads use the modern source of truth.

Verification

  • vendor/bin/phpunit --filter test_is_tour_finished_reads_stripped_legacy_user_settings_meta --no-coverage → passed when mirrored into the dev checkout.
  • vendor/bin/phpunit --filter 'test_is_tour_finished_(reads_user_meta|falls_back_to_legacy_user_setting|reads_stripped_legacy_user_settings_meta)' --no-coverageOK (3 tests, 9 assertions) when mirrored into the dev checkout.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php → no errors.
  • Pre-commit hook passed for committed production PHP file.
  • Browser verification on local WP 7.0-RC2 after simulating stripped legacy state:
    • wu_tour_checkoutformlist=1&wu_tour_wpultimodashboard=1
    • checkout forms URL: window.wu_tours null, .shepherd-element count 0
    • dashboard URL: window.wu_tours null, .shepherd-element count 0
    • fallback backfilled wu_tour_finished_checkout_form_list=1 and wu_tour_finished_wp_ultimo_dashboard=1

Notes

The broader Tours_Test file still has a pre-existing PHPCS warning for wp_register_script(..., false, ...) in an unrelated test path; this PR does not introduce that warning.


aidevops.sh v3.19.0 plugin for OpenCode v1.15.10 with gpt-5.5 spent 5h 33m and 14,327 tokens on this as a headless worker.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced tour completion persistence with improved legacy compatibility to properly recognize and migrate previous completion status from various data formats, ensuring users' tour history is reliably preserved across system updates.
  • Tests

    • Added regression test to verify tour completion status is correctly recognized from legacy data sources and properly migrated to the current system.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR hardens tour completion persistence by adding legacy detection that recognizes tour-finished flags stored under hyphenated, underscored, or sanitized key variants in user-settings, then backfills them to the authoritative user meta store with a regression test validating the behavior.

Changes

Tour Completion Legacy Compatibility

Layer / File(s) Summary
Legacy completion detection helpers
inc/ui/class-tours.php
Protected methods get_legacy_setting_keys() and is_tour_finished_legacy() enumerate possible legacy user-setting key variants (hyphen, underscore, sanitized) and detect whether any of those variants indicate the tour was finished by checking the user-settings meta payload.
is_tour_finished() legacy checker integration
inc/ui/class-tours.php
is_tour_finished() replaces the previous fallback with the new legacy checker; when a legacy finished flag is found, it backfills the current user meta flag and returns the legacy result, ensuring future reads use user meta as the authoritative source.
Legacy user-settings regression test
tests/WP_Ultimo/UI/Tours_Test.php
Test method test_is_tour_finished_reads_stripped_legacy_user_settings_meta() seeds legacy user-settings with a hyphen-stripped tour key, verifies the legacy key is recognized, and confirms the user meta finished flag is backfilled to 1.

Possibly Related PRs

  • Ultimate-Multisite/ultimate-multisite#1268: Both PRs change tour completion persistence in is_tour_finished() to derive from user meta with legacy get_user_setting() fallback and add regression tests for meta backfill behavior.
  • Ultimate-Multisite/ultimate-multisite#365: Both PRs modify tour completion logic in inc/ui/class-tours.php—this PR hardens the legacy read/backfill path while the related PR adjusts the write path in mark_as_finished().

Poem

🐰 With hyphenated dreams of tours now past,
We search through legacy to find what lasts.
Strip, underscore, and sanitize away—
Then backfill truth in meta's brighter day! ✨


🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title precisely summarizes the main change: adding support for stripped legacy tour setting keys, which is the core fix described in the PR objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tours-legacy-key-fallback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@github-actions

Copy link
Copy Markdown

Performance Test Results

Performance test results for 1c3a49b are in 🛎️!

Note: the numbers in parentheses show the difference to the previous (baseline) test run. Differences below 2% or 0.5 in absolute values are not shown.

URL: /

Run DB Queries Memory Before Template Template WP Total LCP TTFB LCP - TTFB
0 56 49.13 MB 552.00 ms 78.50 ms (-17.00 ms / -22% ) 630.00 ms 1238.00 ms 1186.35 ms 51.40 ms
1 56 49.13 MB 548.00 ms 77.50 ms 626.50 ms 1218.00 ms 1170.05 ms 49.15 ms

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@inc/ui/class-tours.php`:
- Around line 130-136: The loop in is_legacy_tour_finished($id, $user_id) calls
get_user_setting($setting_key) which reads the current browser user’s settings;
change the check to read settings for the passed $user_id instead (e.g. use
get_user_option($setting_key, $user_id) or get_user_meta($user_id, $setting_key,
true)) so legacy backfill uses the intended $user_id; update the loop that
iterates get_legacy_setting_keys($id) and also ensure subsequent retrieval of
$stored_settings continues to use get_user_option('user-settings', $user_id).

In `@tests/WP_Ultimo/UI/Tours_Test.php`:
- Around line 350-356: The test must clear cookie/global user-setting state so
is_legacy_tour_finished() actually parses stripped keys: before calling
$get_legacy_keys->invoke(...) and $is_finished->invoke($instance,
'checkout-form-list', $user_id) clear any wp-settings cookie and updated-user
cache (unset $_COOKIE['wp-settings-'.$user_id] and unset
$GLOBALS['_updated_user_settings'][$user_id]) so get_user_setting() won't return
stale values; update the
test_is_tour_finished_reads_stripped_legacy_user_settings_meta() sequence to
unset those entries immediately after update_user_option(...) and before
invoking the reflection helpers ($get_legacy_keys and $is_finished).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: af0148c4-f9b0-4832-b5ac-db3e92d52047

📥 Commits

Reviewing files that changed from the base of the PR and between 0997856 and 8d1d186.

📒 Files selected for processing (2)
  • inc/ui/class-tours.php
  • tests/WP_Ultimo/UI/Tours_Test.php

Comment thread inc/ui/class-tours.php
Comment on lines +130 to +136
foreach ($this->get_legacy_setting_keys($id) as $setting_key) {
if (get_user_setting($setting_key, false)) {
return true;
}
}

$stored_settings = get_user_option('user-settings', $user_id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In WordPress core, does get_user_setting( $name, $default ) read only the current user's wp-settings cookie/cache, or can it resolve settings for an arbitrary user ID? Please include the function signature and a short summary of its lookup behavior.

💡 Result:

Function signature: get_user_setting( $name, $default_value = false) [1] Short summary of lookup behavior (WordPress core): - get_user_setting calls get_all_user_settings, then returns the setting value from the returned array for key $name, or returns $default_value if the key is not present. [1][2] - get_all_user_settings only resolves settings for the current request’s “current user” (it uses get_current_user_id). If there is no current user ID, it returns an empty array. [2] - When a cookie is available, get_all_user_settings reads the user-settings cookie named wp-settings-{user_id} from $_COOKIE (and if not present, it falls back to the user option user-settings in the database for that same $user_id). [2] Answer to your question: In WordPress core, get_user_setting($name, $default_value) does not let you resolve settings for an arbitrary user ID. It looks up settings only for the current user ID for the current request, and it prefers the current browser’s wp-settings-{user_id} cookie (with a database fallback if the cookie is absent) rather than an arbitrary user ID argument. [2][1]

Citations:


Scope get_user_setting() to the requested user to prevent cross-user legacy backfill

get_user_setting( $name, $default_value = false ) is backed by get_all_user_settings(), which resolves settings for the current request’s current user (cookie wp-settings-{current_user_id} with DB fallback). Since is_legacy_tour_finished($id, $user_id) calls get_user_setting() without using $user_id, it can read the current browser user’s legacy cookie while then backfilling meta for the passed $user_id.

Proposed fix
 	protected function is_legacy_tour_finished($id, $user_id) {
 
-		foreach ($this->get_legacy_setting_keys($id) as $setting_key) {
-			if (get_user_setting($setting_key, false)) {
-				return true;
-			}
-		}
+		if (get_current_user_id() === (int) $user_id) {
+			foreach ($this->get_legacy_setting_keys($id) as $setting_key) {
+				if (get_user_setting($setting_key, false)) {
+					return true;
+				}
+			}
+		}
 
 		$stored_settings = get_user_option('user-settings', $user_id);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
foreach ($this->get_legacy_setting_keys($id) as $setting_key) {
if (get_user_setting($setting_key, false)) {
return true;
}
}
$stored_settings = get_user_option('user-settings', $user_id);
if (get_current_user_id() === (int) $user_id) {
foreach ($this->get_legacy_setting_keys($id) as $setting_key) {
if (get_user_setting($setting_key, false)) {
return true;
}
}
}
$stored_settings = get_user_option('user-settings', $user_id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/ui/class-tours.php` around lines 130 - 136, The loop in
is_legacy_tour_finished($id, $user_id) calls get_user_setting($setting_key)
which reads the current browser user’s settings; change the check to read
settings for the passed $user_id instead (e.g. use get_user_option($setting_key,
$user_id) or get_user_meta($user_id, $setting_key, true)) so legacy backfill
uses the intended $user_id; update the loop that iterates
get_legacy_setting_keys($id) and also ensure subsequent retrieval of
$stored_settings continues to use get_user_option('user-settings', $user_id).

Comment on lines +350 to +356
update_user_option($user_id, 'user-settings', 'wu_tour_checkoutformlist=1', false);

$this->assertSame('wu_tour_checkoutformlist=1', get_user_option('user-settings', $user_id));
$this->assertContains('wu_tour_checkoutformlist', $get_legacy_keys->invoke($instance, 'checkout-form-list'));

$this->assertTrue($is_finished->invoke($instance, 'checkout-form-list', $user_id));
$this->assertSame('1', get_user_meta($user_id, $meta_key, true));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 "wp-settings-|_updated_user_settings|test_is_tour_finished_falls_back_to_legacy_user_setting|test_is_tour_finished_reads_stripped_legacy_user_settings_meta" tests/WP_Ultimo/UI/Tours_Test.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3005


🏁 Script executed:

#!/bin/bash
set -euo pipefail
nl -ba tests/WP_Ultimo/UI/Tours_Test.php | sed -n '320,390p'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 124


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n tests/WP_Ultimo/UI/Tours_Test.php | sed -n '320,390p'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3274


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "function is_tour_finished" -S . --glob='*.php'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 168


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '130,260p' inc/ui/class-tours.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3570


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "\$_COOKIE\[['\"]wp-settings-'|_updated_user_settings|wp-settings-" tests/WP_Ultimo/UI/Tours_Test.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 637


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n tests/WP_Ultimo/UI/Tours_Test.php | sed -n '260,340p'

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3999


🏁 Script executed:

#!/bin/bash
rg -n -C3 "wp-settings-|_updated_user_settings|test_is_tour_finished_falls_back_to_legacy_user_setting|test_is_tour_finished_reads_stripped_legacy_user_settings_meta" tests/WP_Ultimo/UI/Tours_Test.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3005


Clear wp-settings-* cookie/cache in stripped legacy meta test

test_is_tour_finished_reads_stripped_legacy_user_settings_meta() doesn’t isolate cookie-backed get_user_setting() state. Since is_legacy_tour_finished() checks get_user_setting() before parsing get_user_option('user-settings', ...), stale $_COOKIE['wp-settings-{user_id}'] / $GLOBALS['_updated_user_settings'] can make the test pass without exercising the stripped-key parsing path.

Proposed fix
+		$cookie_name                      = 'wp-settings-' . $user_id;
+		$prior_cookie                     = $_COOKIE[ $cookie_name ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- test stash, no user input.
+		$prior_updated_settings           = $GLOBALS['_updated_user_settings'] ?? null;
+		unset($_COOKIE[ $cookie_name ]);
+		$GLOBALS['_updated_user_settings'] = null;
+
-		update_user_option($user_id, 'user-settings', 'wu_tour_checkoutformlist=1', false);
-
-		$this->assertSame('wu_tour_checkoutformlist=1', get_user_option('user-settings', $user_id));
-		$this->assertContains('wu_tour_checkoutformlist', $get_legacy_keys->invoke($instance, 'checkout-form-list'));
-
-		$this->assertTrue($is_finished->invoke($instance, 'checkout-form-list', $user_id));
-		$this->assertSame('1', get_user_meta($user_id, $meta_key, true));
+		try {
+			update_user_option($user_id, 'user-settings', 'wu_tour_checkoutformlist=1', false);
+
+			$this->assertSame('wu_tour_checkoutformlist=1', get_user_option('user-settings', $user_id));
+			$this->assertContains('wu_tour_checkoutformlist', $get_legacy_keys->invoke($instance, 'checkout-form-list'));
+
+			$this->assertTrue($is_finished->invoke($instance, 'checkout-form-list', $user_id));
+			$this->assertSame('1', get_user_meta($user_id, $meta_key, true));
+		} finally {
+			if (null === $prior_cookie) {
+				unset($_COOKIE[ $cookie_name ]);
+			} else {
+				$_COOKIE[ $cookie_name ] = $prior_cookie;
+			}
+			$GLOBALS['_updated_user_settings'] = $prior_updated_settings;
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/WP_Ultimo/UI/Tours_Test.php` around lines 350 - 356, The test must
clear cookie/global user-setting state so is_legacy_tour_finished() actually
parses stripped keys: before calling $get_legacy_keys->invoke(...) and
$is_finished->invoke($instance, 'checkout-form-list', $user_id) clear any
wp-settings cookie and updated-user cache (unset
$_COOKIE['wp-settings-'.$user_id] and unset
$GLOBALS['_updated_user_settings'][$user_id]) so get_user_setting() won't return
stale values; update the
test_is_tour_finished_reads_stripped_legacy_user_settings_meta() sequence to
unset those entries immediately after update_user_option(...) and before
invoking the reflection helpers ($get_legacy_keys and $is_finished).

@superdav42 superdav42 merged commit abebe7b into main May 26, 2026
11 checks passed
@superdav42

Copy link
Copy Markdown
Collaborator Author

Summary

Fixes remaining admin tours reappearing for users whose dismissed-tour state was saved before the tour ID normalization fix.
Affected URLs observed locally:

  • wp-admin/network/admin.php?page=wp-ultimo-checkout-forms
  • wp-admin/network/admin.php?page=wp-ultimo

Root cause

Prior fixes covered newer persistence paths:

Fix

  • Add legacy key discovery for both normalized and stripped historical key shapes.
  • Read persisted WordPress user-settings directly with get_user_option('user-settings', $user_id) when get_user_setting() does not find the normalized key.
  • When any legacy key proves the tour was already dismissed, backfill the new wu_tour_finished_* meta flag so future reads use the modern source of truth.

Verification

  • vendor/bin/phpunit --filter test_is_tour_finished_reads_stripped_legacy_user_settings_meta --no-coverage → passed when mirrored into the dev checkout.
  • vendor/bin/phpunit --filter 'test_is_tour_finished_(reads_user_meta|falls_back_to_legacy_user_setting|reads_stripped_legacy_user_settings_meta)' --no-coverageOK (3 tests, 9 assertions) when mirrored into the dev checkout.
  • vendor/bin/phpstan analyse inc/ui/class-tours.php → no errors.
  • Pre-commit hook passed for committed production PHP file.
  • Browser verification on local WP 7.0-RC2 after simulating stripped legacy state:
    • wu_tour_checkoutformlist=1&wu_tour_wpultimodashboard=1
    • checkout forms URL: window.wu_tours null, .shepherd-element count 0
    • dashboard URL: window.wu_tours null, .shepherd-element count 0
    • fallback backfilled wu_tour_finished_checkout_form_list=1 and wu_tour_finished_wp_ultimo_dashboard=1

Notes

The broader Tours_Test file still has a pre-existing PHPCS warning for wp_register_script(..., false, ...) in an unrelated test path; this PR does not introduce that warning.


aidevops.sh v3.19.0 plugin for OpenCode v1.15.10 with gpt-5.5 spent 5h 33m and 14,327 tokens on this as a headless worker.


Merged via PR #1271 to main.
Merged by deterministic merge pass (pulse-wrapper.sh).

@superdav42 superdav42 added the review-feedback-scanned Merged PR already scanned for quality feedback label May 26, 2026
superdav42 added a commit that referenced this pull request May 27, 2026
…peating on every page load (#1281)

* fix(tours): mark one-shot tours as finished on render to stop them repeating on every page load

When a tour was rendered without the user explicitly clicking through
to the last step (e.g. they refreshed the page, navigated away mid-walkthrough,
closed the browser tab, or hit the back button before reaching the close
button), the wu_tour_finished_* meta flag was never written. The next page
load would re-render the same tour, producing the user-visible symptom of
"the same admin tour keeps appearing on every page load".

Previous fixes (#1051, #1268, #1271, #1277) ensured the dismissal *could*
persist across cookie / id-normalization / user-scoping edge cases — but
all of them still depended on the AJAX dismissal triggered by Shepherd's
complete / cancel events. If the user never reached the end of the tour,
no AJAX call was made and no flag was stored.

Persist the finished flag synchronously, server-side, the moment a one-shot
tour is queued for display. The Shepherd event handlers in tours.js still
fire markTourFinished for completeness; update_user_meta is idempotent so
the double-write is harmless. Tours registered with $once = false continue
to render on every page load.

Verified on https://ruling-sable.jurassic.ninja (Ultimate Multisite v2.12.0
deploy) by:

- Resetting wu_tour_* user meta and wp_user-settings for the demo user
  in a brand new agent-browser session.
- Loading /wp-admin/network/admin.php?page=wp-ultimo — tour renders once,
  wu_tour_finished_wp_ultimo_dashboard = 1 written immediately.
- Reloading the same URL — tour no longer renders.
- Repeating for /wp-admin/network/admin.php?page=wp-ultimo-checkout-forms
  (checkout-form-list) — same one-shot behaviour confirmed.

* fix(tours): avoid rewriting filter-forced tour state

* fix(e2e): stabilize Cypress login and password reset fixture
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-feedback-scanned Merged PR already scanned for quality feedback

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant